終於我們來到了一個進階的議題 - 動畫。在 Flutter 中,很多內建的組件以及可用的插件可以協助我們建置美觀有設計感的應用程式,但除此之外,Fluttter 還支援動畫效果如透明度、旋轉、變換樣式來操作組件,進一步提升使用者體驗。
本章節將進一步深入組件的操作,Flutter 對於動畫有很好的支援,這些動畫效果可以組合,擴展,使得介面生動有趣。我們將學習包含 Tween 和 AnimatedBuilder 來完成動畫效果。
最後我們會探討一些內建動畫效果的組件,這些組件可以輕易的使用,但不一定適合每一種狀況,不過如果只是需要一些簡單的效果時,是不錯的選擇。
這個章節會介紹:
Transform
類別AnimatedBuilder
Transform
類別前面的文章到這裡我們已經見過了許多組件,但有時候我們需要改變組件的樣式呈現來改善 UX。為了即時反饋使用者的輸入或者提供一些效果,我們可能需要在螢幕上移動組件,改變其大小,甚至變形。如果你曾經使用原生平台語言開發這類效果,會發現其實這不容易。
如同前面提到的,Flutter 非常關注 UI 設計,目標通過簡化原本很複雜的部分使開發者可以比較容易實現功能。
這裡,我們首先介紹 Transform
組件,因為在操作組件上,這是一個非常實用的組件,後續我們會深入這個組件看看其支援的功能。
Transform
組件Transform
組件是 Flutter 框架一致性的最佳例子之一。它是一個單一用途的組件,只是簡單的對子元素套用圖形轉換效果。
如其名,Transform
只會執行單一任務:
Center(
child: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationZ(15 * math.pi / 180),
child: const Text(
"哈囉,世界",
style: TextStyle(fontSize: 24),
),
),
),
如上所見,該組件並不需要很多參數便可完成效果,下面讓我們來看看有哪些參數:
transform
:這是唯一必須的參數,用於描述變形效果並套用到 child
的組件上。該參數型別為 Matrix4
物件,一個 4D 矩陣以數學的形式描述變形,後續我們會更深入的介紹這個 Matrix4
類別。origin
:這是套用變形矩陣座標系統的原點。origin
屬性由 Offset
型別設定,在這個情況下表示的是笛卡爾座標系統中的一個點 (x, y),變形效果會以 origin
為參考點或「軸心」。例如當我們有一個 100 x 100 的方向,預設 origin
為 (50, 50) ,此時旋轉 45 度效果會以中心來旋轉,若改變 origin
為 (0, 0) 旋轉則效果比較接近下垂。總之變形(如旋轉、縮放)會圍繞這個點進行。alignment
:類似 origin
可以用來控制套用變形效果的位置。使用這個屬性可以更彈性指定「原點」。因為 origin
須給定實際座標值,而「對齊」使用相對位置,例如 Alignment.center
不管組件的尺寸,可以讓原點設定為中心。如果同時使用兩者,則效果會疊加。Flutter 會先套用 alignment
然後再以 origin
進行相對位置的調整。transformHitTests
:設定是否在變形後的組件版本中分析命中測試(也就是,點擊)。在 UI 開發中,命中測試是用來確認使用者點擊是否落在特定 UI 元素。而 transformHitTests
為 true
時,點擊區域會隨著組件變形而變化,若為 false
那麼點擊區域保持為原始位置,不受變形影響。filterQuality
:設定子組件在變形狀態下重現時的視覺品質,將其保留為 null
會使子組件保持其原始視覺狀態。它控制在進行縮放或旋轉等變形操作時,如何處理像素以維持圖像質量。當變形可能導致圖像質量下降時(如放大或傾斜),使用適當的 filterQuality
可以改善視覺效果,但較高的設定會增加效能的負擔。child
:套用變形的組件。在 Flutter,變形使用 4D 矩陣來呈現。雖然聽起來好像很複雜令人害怕,但一個 4D 矩陣單純就是一個 4 x 4 的矩陣如下:
\begin{bmatrix}
1 & 0 & 0 & 0 \\
0 & 1 & 0 & 0 \\
0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1 \\
\end{bmatrix}
上面矩陣的值表示一個「單位矩陣」Identity Matrix,這是一個特殊的矩陣,在數學的世界裡就是「不做任何事」就類似任何數乘 1 一樣,也就不會進行任何變形。當矩陣中的值變更時,組件就會以不同的效果進行變形。
矩陣乘法的作用在這裡概略為當我們把一個組件的某個座標乘以一變形矩陣,這個計算結果就是點的新位置。
但一般來說,我們不需要去設定矩陣中的值來實作變形效果。Matrix4
類別包含了一些建構子和方法協助我們控制矩陣而不需要完全了解幾何變換的細節。
identity()
:這個建構子會直接建立一個單位矩陣如同上面呈現。rotationX()
rotationY()
rotationZ()
建構子或者 rotateX()
rotateY()
rotateZ()
方法可以協助我們產生旋轉效果。scale()
方法可以用來套用縮放效果,參數可以使用 x, y, z 值或者 Vector3
和 Vector4
類別。translation()
建構子或 translate()
方法可以移動 x, y, z 軸,同樣參數也可以使用 Vector3
和 Vector4
Transform(
transform: Matrix4.identity()..translate(50.0, 30.0),
child: Container(
width: 100,
height: 100,
color: Colors.red,
),
)
上面
Matrix4.identity()..translate(50.0, 30.0),
中的..
Cascade Notation 級聯符號可以對同一個物件進行多個操作// 例如 var person = Person(); person.name = 'andyyou'; person.age = 37; // 使用級聯符合 var person = Person() ..name = 'andyyou' ..age = 37;
下面讓我們來看看如何使用 Transform
和 Matrix4
實作不同類型的變形效果。
另外,也可以通過 SVG 研究之路 (20) - transform Matrix 來學習相關原理。
當我們希望讓子組件套用旋轉變形效果,通過使用 Transform.rotate()
建構子即可達成。這和 Transform
預設的建構子並沒有太大的差異,主要的不同如下:
transform
屬性:當使用 rotate()
時,我們主要是要套用旋轉,因此不需設定完整的矩陣,只需要使用 angle
即可。angle
屬性可以指定順時針旋轉角度,以弧度為單位。origin
預設情況下,旋轉會相對於組件的中心套用效果,不過我們可以使用 origin
來設定原點。當我們處理旋轉效果時,我們會需要使用 PI 常數協助。為此我們需要匯入數學套件
import 'dart:math';
通常,為了不產生任何疑義,關於常數從何處匯入,我們可以幫匯入指定一個名稱:
import 'dart:math' as math;
然後我們就可以使用 math.pi
來讀取圓周率
Transform.rotate(
angle: -45 * (math.pi / 180.0),
child: ElevatedButton(
child: Text("旋轉按鈕"),
onPressed: () {},
),
);
一個圓總共 360 度,等於 2 PI,PI 就是 180度。從上面的基礎知識我們可以得到要換算
上面程式,我們指定了角度度 -45 度(315度) 然後子組件就會套用旋轉效果。
這個效果如果我們使用預設建構子搭配 Matrix4
則如下為等價的程式碼:
Transform(
transform: Matrix4.rotationZ(-45 * (math.pi / 180.0)),
alignment: Alignment.center,
child: ElevatedButton(
child: Text("旋轉按鈕"),
onPressed: () {},
),
);
為了實現相同效果,我們提供的是圍繞 Z 軸旋轉的 transform
屬性搭配置中。
在我們希望輕鬆的改變組件的大小時,就可以套用縮放效果。類似於 rotate()
建構子和預設建構子沒有太大差異一樣,下面是 Transform.scale()
和預設之間的不同點:
transform
屬性,這裡我們改使用 scale
取代,而不用傳入矩陣。sacle
可用來設定縮放的大小,型別為 double
, 1.0
為原始尺寸。除此之外還可以套用 x 和 y 軸alignment
屬性改變縮放的原點Transform.scale(
scale: 2.0,
child: ElevatedButton(
child: Text("縮放按鈕"),
onPressed: () {},
),
);
上面範例我們設定了 2.0
,child
的組件會放大 2 倍。不過除了 Transform
如果只是改變尺寸我們也可以直接設定 ElevatedButton
的大小
ElevatedButton(
style: ButtonStyle(
fixedSize: MaterialStateProperty.all(Size(200, 60)),
// 或者使用 minimumSize 設定最小尺寸
// minimumSize: MaterialStateProperty.all(Size(200, 60)),
),
child: const Text("調整按鈕大小"),
onPressed: () {},
),
同樣的也可以使用一般的 Transform
建構子達成一樣的效果:
Transform(
transform: Matrix4.identity()..scale(2.0, 2.0),
alignment: Alignment.center,
child: ElevatedButton(
child: const Text("縮放按鈕"),
onPressed: () {},
),
)
關於級聯運算子:
你大概注意到上面 sacle
方法也使用了級聯運算子。在前面我們已經有簡單的說明,通常我們使用一個 .
用來存取方法或其屬性,而 ..
可以操作回傳的物件而不用另外建立變數。
另外上面 rotationZ()
並沒有使用級聯運算子是因為該方法直接回傳了一個新的矩陣,而縮放 sacle
方法本身回傳型別為 void
,作用為修改現有的矩陣。
位移效果通常會出現在動畫中,後續 Animation
我們會介紹。而這邊我們先介紹 translate()
和預設 Transform
建構子的差異:
transform
和 alignment
屬性,效果通過 offset
參數設定而不用使用矩陣。offset
單純設定 child
組件的位移Transform.translate(
offset: Offset(30, 30),
child: ElevatedButton(
child: const Text("移動按鈕"),
onPressed: () {},
),
);
同樣的對照預設建構子的用法
Transfrom(
transform: Matrix4.translationValues(30, 30, 0),
child: ElevatedButton(
child: const Text("移動按鈕"),
onPressed: () {},
),
);
除了上面介紹的各種效果,我們還可以組合上面介紹的效果例如旋轉加上縮放。組合變形一般可以通過下面兩種方式達成:
Transform
組件搭配 Matrix4
提供的各種方法組合而成。Transform
組件嵌套的方式// 嵌套的方式
Transform.translate(
offset: Offset(70, 200),
child: Transform.rotate(
angle: -45 * (math.pi / 180.0),
child: Transform.scale(
scale: 2.0,
child: ElevatedButton(
child: Text("組合多種效果"),
onPressed: () {},
),
),
),
);
如你所見,我們使用了多個 Transform
組合效果,雖然比較容易理解和修改,但缺點就是我們在樹狀結構中加入大量組件。除了影響效能外,當我們同時對一個組件加入多個變換時,必須注意效果的順序。您可以自己嘗試 - 交換 Transform
組件的位置會導致不同的結果。
另一種作法便是使用預設 Transform
組件搭配 Matrix4
Transform(
alignment: Alignment.center,
transform: Matrix4.translationValues(70, 100, 0)
..rotateZ(-45 * (math.pi / 180.0))
..scale(2.0, 2.0),
child: ElevatedButton(
child: Text("多種效果"),
onPressed: () {},
),
);
就像前面的例子一樣,我們指定對齊子組件的中心,然後使用 Matrix4
設定變形效果。這與多個 Transform
組件的版本效果很接近,但不需要大量嵌套組件。
對於複雜的組合,考慮使用單一的 Matrix4
變形效果可以有比較好的效能。
Flutter 廣泛的支持動畫功能,提供了多種方式來幫組件增加動畫效果。此外還有一些組件內建動畫。雖然 Flutter 簡化了涉及動畫的複雜性,但在進一步深入之前我們還是需要理解一些重要概念。
Animation<T>
類別在 Flutter, Animation
類別包含一個狀態和 T
型別的值,其中 T
在 Animation
類別實例化時定義。而動畫的狀態即對應它是正在執行還是已經完成;狀態值會隨著動畫的進行而改變,這個值即對應動畫執行過程組件的變化。
這個類別也揭露 callback 因此其他類別可以得知動畫目前的狀態。一個 Animation<T>
物件只負責揭露狀態和屬性值。它並不知道任何視覺反饋,螢幕呈現,或如何渲染即 build()
方法。通過 Animation
類別隨著間隔時間產生的值會被其他組件用來處理它們的動畫。
其中最常見的動畫類型就是 Animation<double>
型別,因為 double
常被用來表示動畫的進度,並用比例的概念操作任何類型的值。
Animation
類別在決定的最小值和最大值之間生成一系列的值。這個過程也被稱為插值 Interpolation 而不只是給出線性值的進程,進程可以是線性、階梯或曲線。Flutter 也提供了多種操作動畫的函式和工具:
AnimationController
雖然類別名稱為 Controller 但它不是用來直接控制動畫物件,它繼承 Animation
用來控制狀態值,也就是控制動畫的時間,方向和持續時間,可以啟動、停止、反轉動畫。CurvedAnimation
這是一個將曲線套用到其他 Animation
的動畫。支援一系列內建的曲線,可以用這些曲線控制生成的動畫值。也就是修改動畫的速度曲線。Tween
給定開始、結束值,協助建立其間線性的插值Animation
類別提供了在運行期間存取狀態和值的方法。通過狀態監聽,可以得知動畫的開始,結束或者反向執行。通過使用 addStatusListener()
方法,可對應動畫開始或結束事件來操作我們的組件。同樣的,我們可以用 addListener()
方法監聽「值」,這樣每次「動畫值」變化時我們都會收到通知,就可以使用 setState
方法重建我們的組件。
另外,使用 addListener
搭配 setState
可能在複雜動畫中影響性能,對於希望更高效的實現,考慮使用 AnimatedBuilder
或 AnimatedWidget
。至此我們概略的介紹了關於動畫的一些概念。
AnimationController
是 Flutter 中最常使用的動畫相關類別之一。延伸自 Animation<double>
類別並加入一些基本控制動畫的方法。
如同前面提到的,Animation
類別是 Flutter 動畫的基礎,但是它不包含直接控制動畫的方法。AnimationController
可以為動畫概念加入各種控制。
AnimationController
加入播放、回放動畫以及停止的功能接著,讓我們來看看 AnimationController
建構子範例以及其主要屬性:
var controller = AnimationController(
value: 0.0, // 初始值
duration: Duration(seconds: 2), // 動畫持續時間
reverseDuration: Duration(seconds: 2), // 動畫持續時間
debugLabel: 'Animation', // 用於調試的標籤
lowerBound: 0.0, // 最小值
upperBound: 1.0, // 最大值
animationBehavior: AnimationBehavior.normal,
vsync: this, // TickerProvider
);
var animation = Tween<double>(begin: 0.0, end: 1.0).animate(controller);
value
動畫值的初始值,如果沒有設定預設為 lowerBound
duration
動畫執行的時間reverseDuration
當動畫以反向執行時的執行時間,預設情況下,動畫不會自動反向執行,如果沒有明確調用反向動畫,reverseDuration
是不會自動生效的。debugLabel
協助除錯的字串,用來識別時那一個 Controller 的輸出。lowerBound
不得為 null,為動畫值的最小值upperBound
不得為 null ,它是動畫值的最大值,通常是執行的結束值。animationBehavior
這個參數設定了當動畫被停用,通常是出於無障礙的考慮。如果設定為 AnimationBehavior.normal
當觸發停用時,動畫時間會被縮短。如果是 AnimationBehavior.preserve
那麼則保持原來的行為。vsync
其值為一個 TickerProvider
物件,Controller 利用它在每次幀觸發時獲得信號。它確保動畫只在可見時運行,防止動畫消耗資源。關於 TickerProvider
後續我們會深入介紹。
TickerProvider
介面,擴展類別使其支援建立 Ticker
物件的方法。Ticker
是 Flutter 動畫系統的核心。每當觸發新的一幀,就會發出一個 Ticker
物件,使其他物件可以對新的一幀 Frame 執行對應的行為。TickerProvider
它的主要作用是確保只有在 Widget 可見時才建立和運行 Ticker
,避免不必要的資源消耗。
幀的重新渲染是依據裝置「螢幕更新頻率間隔」發動的,因此 Ticker
和螢幕更新率同步,通常是每秒 60 幀。利用這點的動畫能獲得最佳的體驗,而更新率低於動畫可能就會掉幀,某些畫面會跳過不渲染。
這個 Ticker
物件通常是通過 AnimationController
物件間接使用,因此包含動畫的狀態組件須支援 TickerProvider
。因此 State
類別可以擴展 TickerProviderStateMixin
或 SingleTickerProviderStateMixin
。這些 Mixin 實作 TickerProvider
並且可以和 AnimationController
物件共用。如果你有多個動畫,那麼使用 TickerProviderStateMixin
,只有一個動畫的話使用 SingleTickerProviderStateMixin
可以提升效率。
當我們在 AnimationController
的建構子中使用 vsync: this
時,這個 this
指的是當前的 State 物件。這意味著我們的 State 類別必須「混入」(mixin) 某個 TickerProvider 實現,通常是 SingleTickerProviderStateMixin
或 TickerProviderStateMixin
。下面讓我們通過範例慢慢的學習這些物件。
CurvedAnimation
類別用於設定 Animation
類別採用非線性曲線,而不是線性死板地執行動畫。換句話說,它用來調整動畫,改變插值的方法。
我們可以套用曲線來調整動畫值,比如讓一個平移效果產生重力彈跳的感覺,或者更直白地說,讓動畫一開始的效果比較快,接近結束時變慢。
通過 curve
和 reverseCurve
屬性,我們可以分別設定正向播放和反向執行時使用不同的曲線效果。
除了 linear
線性效果外,Curves
類別還提供了多種內建曲線供我們選擇。如果上面的說明還是讓你感到困惑可以參考官方文件的影片
如同之前所見,預設最簡單的起始和結束值分別為 0.0 和 1.0。通過 Tween
類別調整 AnimationController
的範圍。Tween<double>
類別除了預設的浮點數也可以是任何型別,如果有需要,我們還可以自訂 Tween
類別。重點是 Tween
在動畫開始結束之間回傳的動畫值,例如我們可以通過動畫值來改變組件的大小,位置,透明度,顏色等等。
此外,我們還有 Tween
衍生的類別,例如 CurveTween
可以修改在動畫的曲線,ColorTween
產生兩個顏色之間的插值。
目前我們已經大致了解了動畫的基本概念和功能。
處理動畫時,儘管動畫可能千變萬化,但它們通常建立在相似的基礎之上,Tween
物件對於調整動畫的效果非常實用。大部分的情況下我們會使用 AnimationController
、CurvedAnimation
和 Tween
物件來組合動畫。
在我們使用進階自訂 Tween
之前,讓我們來複習「套用 Transform
變形效果的組件」,不過,這次我們改以動畫的方式實作。最終我們會得到相同的效果,但效果更加流暢。
除了像之前,可以直接將按鈕套用旋轉效果,我們也可以使用 AnimationController
類別使其產生漸變效果。下面範例我們將逐步建立類似於使用 Transform
旋轉效果的組件。
StatefulWidget
組件。import 'package:flutter/material.dart';
import 'dart:math' as math;
class RotationButton extends StatefulWidget {
const RotationButton({super.key});
@override
State<RotationButton> createState() => _RotationButtonState();
}
class _RotationButtonState extends State<RotationButton>
with SingleTickerProviderStateMixin { // <- 重點擴展了 SingleTickerProviderStateMixin
// 後續會在這裡增加程式碼
}
在 _RotationButtonState
類別中加入 _angle
以及 _controller
類別成員並進行初始化。後續我們會使用 setState()
調整 _angle
角度。
double _angle = 0.0;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = _createRotationAnimation();
_controller.forward();
}
AnimationController _createRotationAnimation() {
var controller = AnimationController(
vsync: this, // 因為類別擴展了 SingleTickerProviderStateMixin
duration: const Duration(seconds: 3),
debugLabel: '旋轉動畫按鈕'
);
controller.addListener(() {
setState(() {
_angle = (controller.value * 360.0) * (math.pi / 180);
});
});
return controller;
}
Widget _renderButton() {
return Transform.rotate(
angle: _angle,
child: ElevatedButton(
child: const Text('旋轉'),
onPressed: () {
_controller.reset();
_controller.forward();
},
),
);
}
@override
Widget build(BuildContext context) {
return _renderButton();
}
在 State
生命週期結束時,必須 dispose
我們的 Controller 物件,避免記憶體洩漏。
@override
void dispose() {
_controller.dispose();
super.dispose();
}
如果希望使用不同的曲線,可以使用 CurveTween
範例如下:
AnimationController _createBounceInRotationAnimation() {
var controller = AnimationController(
vsync: this,
debugLabel: "範例",
duration: Duration(seconds: 3),
);
var animation = controller.drive(
CurveTween(
curve: Curves.bounceIn,
)
);
animation.addListener(() {
setState(() {
_angle = (animation.value * 360.0) * (math.pi / 180);
});
});
return controller;
}
這裡我們使用 drive()
方法建立了 animation
並且傳入了希望的 CurveTween
物件。注意到這裡我們使用 animation
來註冊監聽而不是 controller
,這是因為我們希望動畫值套用曲線效果。
下面是完整範例:
class RotationButton extends StatefulWidget {
const RotationButton({super.key});
@override
State<RotationButton> createState() => _RotationButtonState();
}
class _RotationButtonState extends State<RotationButton>
with SingleTickerProviderStateMixin {
double _angle = 0.0;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = _createRotationAnimation();
_controller.forward();
}
AnimationController _createRotationAnimation() {
var controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
debugLabel: '旋轉動畫按鈕'
);
controller.addListener(() {
setState(() {
_angle = (controller.value * 360.0) * (math.pi / 180);
});
});
return controller;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _renderButton() {
return Transform.rotate(
angle: _angle,
child: ElevatedButton(
child: const Text('旋轉'),
onPressed: () {
_controller.reset();
_controller.forward();
},
),
);
}
@override
Widget build(BuildContext context) {
return _renderButton();
}
}
另外提供一個比較進階的範例:
class RotationButton extends StatefulWidget {
const RotationButton({super.key});
@override
State<RotationButton> createState() => _RotationButtonState();
}
class _RotationButtonState extends State<RotationButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller =
AnimationController(vsync: this, duration: const Duration(seconds: 3));
_animation = Tween(begin: 0.0, end: 1.0).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _renderRotationButton() {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.rotate(
angle: (_animation.value * 360) / (math.pi / 180),
child: child,
);
},
child: ElevatedButton(
child: const Text("旋轉按鈕"),
onPressed: () {
if (_controller.status == AnimationStatus.completed) {
_controller.reset();
}
_controller.forward();
}),
);
}
@override
Widget build(BuildContext context) {
return _renderRotationButton();
}
}
AnimatedBuilder
的作用和 setState
類似,它會在動畫值產生變化時自動重新渲染。兩個範例中我們先使用我們已經熟悉的狀態 setState
來實作展示其概念,實務上 AnimatedBuilder
搭配 Animation
的範例則有比較好的效能,這裡提供範例可以自行比較學習。
為了建立縮放動畫並且實現比直接改變尺寸更流暢的 UI 效果,我們可以再次利用 AnimationController
達成效果。
這一次我們的將使用 scale
效果套用到 ElevatedButton
組件上。為了更加熟練的掌握 Flutter 的概念,這次我們由下而上,先從按鈕渲染開始:
Widget _renderButton() {
return Transform.scale(
scale: _scale,
child: ElevatedButton(
child: const Text('縮放按鈕'),
onPressed: () {
// 這裡我們儘量介紹各種 API
// 等價於 _controller.status == AnimationStatus.completed
if (_controller.isCompleted) {
_controller.reverse();
} else if (_controller.isDismissed) {
_controller.forward();
}
}),
);
}
可以預期我們會有一個 _scale
屬性還有在 onPressed
執行一些操作,這裡包含了反向播放動畫 reverse()
若動畫播完了就反向,在初始狀態就正常播放。
和旋轉動畫類似我們需要建立 _controller
物件,但是使用的參數些微不同:
AnimationController _createAnimationController() {
var controller = AnimationController(
vsync: this,
lowerBound: 1.0,
upperBound: 2.0,
duration: const Duration(seconds: 2),
);
controller.addListener(() {
setState(() {
_scale = controller.value;
});
});
return controller;
}
如你所見我們這次使用了 lowerBound
和 upperBound
。lowerBound
設為 1.0:確保按鈕不會小於其原始大小。upperBound
設為 2.0:允許按鈕最大放大到原始大小的兩倍。
完整程式碼如下:
class ScaleButton extends StatefulWidget {
const ScaleButton({super.key});
@override
State<ScaleButton> createState() => _ScaleButtonState();
}
class _ScaleButtonState extends State<ScaleButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
double _scale = 1.0;
@override
void initState() {
super.initState();
_controller = _createAnimationController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _renderButton();
}
Widget _renderButton() {
return Transform.scale(
scale: _scale,
child: ElevatedButton(
child: const Text('縮放按鈕'),
onPressed: () {
if (_controller.isCompleted) {
_controller.reverse();
} else if (_controller.isDismissed) {
_controller.forward();
}
}),
);
}
AnimationController _createAnimationController() {
var controller = AnimationController(
vsync: this,
lowerBound: 1.0,
upperBound: 2.0,
duration: const Duration(seconds: 2),
);
controller.addListener(() {
setState(() {
_scale = controller.value;
});
});
return controller;
}
}
其他問題處理:如果你在開發過程遇到無法開啟 iOS 模擬器的情況「Unable to boot the Simulator」可以到 🍎 > 系統設定 > 一般 > 儲存空間 > 開發者 > 刪除快取。
跟上面我們完成的旋轉和縮放動畫一樣,我們也可以實現比較流暢的位移效果。大部分都跟上面類似,差異只是我們改成使用 Transform.translate
。這次我們須使用不同型別的動畫值,不再是 double
。讓我們來看看如何實作 Offset
動畫
AnimationController _createAnimationController() {
var controller = AnimationController(
vsync: this,
debugLabel: "位移動畫",
duration: Duration(seconds: 2),
);
var animation = controller.drive(
Tween<Offset>(
begin: Offset.zero,
end: Offset(70, 200),
)
);
animation.addListener(() {
setState(() {
_offset = animation.value,
});
});
return controller;
}
如你所見,我們使用了不同的方式修改 Offset
也就是 Tween<Offset>
物件,並使用 drive()
傳入 AnimationController
簡單的說就是給定 Tween<Offset>
開始結束的座標,剩下的複雜計算由該物件替我們自動計算。因為 Offset
可以覆寫一些計算方法,因此可以調整中間偏移量的計算。
總結來說 AnimationController
的角色負責控制動畫的時間,何時開始,結束,暫停或反轉。Tween
是一個轉換工具可以將 Controller 的 0.0 - 1.0 轉換為實際需要的值,而 Animation
是 AnimationController
和 Tween
結合的結果,代表隨著時間變化的實際值。
接著,讓我們來介紹上面提到的 AnimatedBuilder
。
回顧我們上面的程式碼,可以發想一個問題;我們的動畫邏輯和 UI 邏輯混合在一起例如 _renderButton()
中同時處理了組件和使用 _scale
狀態,加上在按鈕中調用 forward
等。
針對簡單的應用這麼做沒什麼問題,但隨著應用複雜度提升,這慢慢的會變成問題且導致難以維護。
而 AnimatedBuilder
除了上面提到的效能外,就是協助我們來完成分離職責的任務。我們的組件無論是 ElevatedButton
還是其他東西,都不需要知道它是在動畫中被渲染。而將 build
方法進一步分解成各個單一職責的組件可以說是 Flutter 的基本概念之一。
AnimatedBuilder
組件的存在就是為了幫助我們將動畫邏輯與 UI 構建分離,使得創建複雜的動畫效果變得簡單。
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: _animation.value,
height: 50,
child: child,
);
},
child: ElevatedButton(
child: Text('動畫效果按鈕'),
onPressed: () {},
),
),
);
}
如上面看到的,這裡有幾個比較重要的屬性:
animation
通常是一個 AnimationController
或其衍生物件,型別為 Listenable
物件,常見產生的方式如下
var _animation = AnimationController(...); // 基本控制器
var _animation = Tween<double>(...).animate(_controller); // 使用 Tween
var _animation = CurvedAnimation(parent: _controller, curve: Curves.easeIn); // 使用曲線
var _animation = Tween<double>(...).animation(CurvedAnimation(...)); // 組合使用
var _animation = _controller.drive(Tween<double>(...)); // 使用 drive 方法
builder
根據動畫值調整組件的地方,根據當前狀態動態修改 UI。
child
傳入的組件,用於定義不需要在每次動畫更新時重建的 UI 部分,提高效率。
為了分解我們的程式碼、調整動畫使其容易維護,我們必須分離每個負責的內容:
AnimationController
類別的部分保持不變。build
:換成使用 AnimatedBuilder
組件,這裡我們會抽離和按鈕動畫相關大部分的程式碼。ElevatedButton
。首先,我們來重構建立動畫的部分:
(AnimationController, Animation) _createAnimation() {
final controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
debugLabel: '旋轉按鈕動畫'
);
final animation = controller.drive(CurveTween(curve: Curves.bounceIn));
return (controller, animation);
}
我們一樣建立了 AnimationController
和 Animation
,差別是我們不再通過監聽器使用 setState
。
但這次我們須回傳 animation
這是因為我們需要在 AnimatedBuilder
中使用這個動畫值。回傳的部分我們使用了 Dart 3 的新 Record
型別。
新的 AnimatedBuilder
作法依舊須 dispose()
@override
void dispose() {
_controller.dispose();
super.dispose();
}
狀態組件依舊是必須的,我們來處理初始化階段
class _RotationButtonState extends State<RotationButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation _animation;
@override
void initState() {
super.initState();
final (controller, animation) = _createAnimation();
_controller = controller;
_animation = animation;
}
// ...
}
最後完整範例如下:
import 'package:flutter/material.dart';
import 'dart:math' as math;
class RotationButton extends StatefulWidget {
const RotationButton({super.key});
@override
State<RotationButton> createState() => _RotationButtonState();
}
class _RotationButtonState extends State<RotationButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation _animation;
@override
void initState() {
super.initState();
final (controller, animation) = _createAnimation();
_controller = controller;
_animation = animation;
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
child: ElevatedButton(
child: const Text('旋轉按鈕'),
onPressed: () {
print("開始旋轉");
print("${_animation.status}");
if (_animation.isCompleted || _animation.isDismissed) {
_controller.reset();
_controller.forward();
}
},
),
builder: (context, child) {
return Transform.rotate(
angle: _animation.value * 2.0 * math.pi,
child: child,
);
},
);
}
(AnimationController, Animation) _createAnimation() {
final controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
debugLabel: '旋轉按鈕動畫');
final animation = controller.drive(CurveTween(curve: Curves.bounceIn));
return (controller, animation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
如同之前提到的,我們不用在建立 Controller 時使用監聽搭配 setState
- AnimatedBuilder
會負責處理相關任務,並將重新渲染限制在 AnimatedBuilder
類別的子組件範圍內。到此我們已經了解了動畫的基礎知識。
除了我們上面自己簡直的動畫效果,Flutter 還有內建一整套的動畫組件。這些組件包含了一些常見的效果,可以更容易的使用。舉例來說我們的組件設定了某個顏色,然後我們通過 setState
變更顏色值,顏色的變化會自動套用動畫。
第一個要看的就是最強大的 AnimatedContainer
組件。這個組件類似於 Container
,但加入了一些屬性支援動畫處理。
Container(
width: _winner ? 50 : 400,
child: Image.asset(...),
),
一開始 _winner
值為 false,然後使用 setState
變更為 true,圖片就會從 50px 擴展到 400px。
進一步我們還可以加入其他屬性:
AnimatedContainer(
width: _winner ? 50 : 400,
child: Image.asset(...),
duration: Duration(seconds: 2),
curve: Curves.bounceOut,
),
在 Flutter 中隱式動畫組件,常被叫做 AnimatedFoo
這個 Foo 是非動畫版本的名字。這類組件很多,具體例子如下:
Stack
組件中使用,移動位置相關變化的動畫效果如果不是太複雜的效果,這些組件可以幫助你節省不少開發時間。
本文我們學習了:
Transform
變形來改變組件。Matrix4
。AnimationController
、CurvedAnimation
和 Tween
。AnimatedBuilder
組件重構優化。Animated
類別。希望你在這個章節學習到動畫相關的知識,後續在需求上可以進一步深入學習。